Skip to content

Introduce API Tokens with cluster_permissions and index_permissions directly associated with the token#5443

Open
cwperks wants to merge 63 commits into
opensearch-project:mainfrom
cwperks:feature/api-tokens-cwperx
Open

Introduce API Tokens with cluster_permissions and index_permissions directly associated with the token#5443
cwperks wants to merge 63 commits into
opensearch-project:mainfrom
cwperks:feature/api-tokens-cwperx

Conversation

@cwperks
Copy link
Copy Markdown
Member

@cwperks cwperks commented Jun 25, 2025

Description

Re-basing #5225 with the latest changes from main.

This PR introduces API Tokens — a new capability in the Security plugin that allows security admins to issue long-lived, scoped tokens and associate permissions directly with the token.

How it works

API Tokens are opaque tokens with the format os_<random>. When a token is created, a SHA-256 hash of the plaintext token is stored in a system index, .opensearch_security_api_tokens. The plaintext token is returned once at creation time and never stored. On each request, the incoming token is hashed and looked up in an in-memory cache populated from the index.

Tokens are authenticated via the Authorization: ApiKey <token> header.

What is novel about this approach compared to OBO tokens is that permissions are scoped directly to the token rather than derived from the issuing user's roles. An admin can issue a token with only the permissions it needs — for example, read-only access to a single index — regardless of the admin's own permissions. This enforces the principle of least privilege and is a key building block toward deprecating Roles Injection, the current practice for how plugins run async jobs with user-scoped permissions.

Revocation model

Tokens use a soft-delete revocation model. When a token is revoked via DELETE /_plugins/_security/api/apitokens/{id}, the document in the index is updated with a revoked_at timestamp rather than being deleted. This means:

  • Revoked tokens remain visible in the list endpoint with a revoked_at field, enabling UIs to display revocation history and audit trails.
  • Revocation is synchronous — the cache refresh is broadcast to all nodes and confirmed before the response is returned, so the token is immediately unusable cluster-wide.
  • During cache reload, tokens with revoked_at set are excluded from the in-memory authentication maps, so they cannot be used to authenticate.

Token Identity & Naming

Token names serve as the user-facing identity. They must be unique and match [a-zA-Z0-9_-]+. In audit logs and internal user contexts, tokens appear as token:<name> (e.g., token:my-service-token). The SHA-256 hash used for authentication lookup is internal-only and never exposed in API responses or audit logs.

System Index Protection

API tokens are denied access to all system indices regardless of their granted permissions. This is enforced at the privilege evaluation layer via SpecialIndexProtection and applies to both legacy and V4 privilege evaluation modes.


API Reference

Create API Token

POST /_plugins/_security/api/apitokens

The create request accepts duration_seconds — how long the token lives, in seconds. The cluster setting max_duration_seconds controls the maximum allowed duration.

Request:

{
  "name": "my-token",
  "cluster_permissions": ["cluster:monitor/health"],
  "index_permissions": [
    {
      "index_pattern": ["logs-*"],
      "allowed_actions": ["indices:data/read/search"]
    }
  ],
  "duration_seconds": 3600
}

Response:

{
  "id": "Nd_pMRWeAC93ZGMhRa5CxX",
  "token": "os_abc123..."
}

The id is used to manage the token, such as listing or revoking it. The plaintext token is returned once and never stored — save it immediately.

List API Tokens

GET /_plugins/_security/api/apitokens

Returns all tokens, including revoked ones. Revoked tokens include a revoked_at field (epoch millis). The expires_at field is the epoch millis timestamp when the token expires.

Response:

[
  {
    "id": "Nd_pMRWeAC93ZGMhRa5CxX",
    "name": "my-token",
    "iat": 1742000000000,
    "expires_at": 1742003600000,
    "cluster_permissions": ["cluster:monitor/health"],
    "index_permissions": [
      {
        "index_pattern": ["logs-*"],
        "allowed_actions": ["indices:data/read/search"]
      }
    ]
  },
  {
    "id": "Xf_qNSZeBC04AHNiSb6DyY",
    "name": "old-token",
    "iat": 1741000000000,
    "expires_at": 1741003600000,
    "revoked_at": 1741500000000,
    "cluster_permissions": ["cluster:monitor/health"],
    "index_permissions": []
  }
]
Revoke API Token

DELETE /_plugins/_security/api/apitokens/{id}

Response:

{
  "message": "Token Nd_pMRWeAC93ZGMhRa5CxX revoked successfully."
}

Revocation is a soft-delete — the token metadata is retained with a revoked_at timestamp. The token is immediately unusable after the response is returned. The cache refresh is broadcast synchronously to all nodes before the response is sent.

Using a Token

Pass the token in the Authorization header using the ApiKey scheme:

Authorization: ApiKey os_abc123...

Example — search a permitted index:

GET /logs-2025/_search
Authorization: ApiKey os_abc123...

Response:

{
  "hits": {
    "total": { "value": 3, "relation": "eq" },
    "hits": [ ... ]
  }
}

Example — attempt a forbidden action:

DELETE /logs-2025
Authorization: ApiKey os_abc123...

Response:

{
  "error": {
    "type": "security_exception",
    "reason": "no permissions for [indices:admin/delete]"
  },
  "status": 403
}

Issues Resolved

Partially resolves #4009, limited to security admins in the initial release.

Check List

  • New functionality includes testing
  • New functionality has been documented
  • New Roles/Permissions have a corresponding security dashboards plugin PR
  • API changes companion pull request created
  • Commits are signed per the DCO using --signoff
  • V4 (nextgen) privilege evaluation mode supported and tested

derek-ho and others added 23 commits November 14, 2024 10:47
…00 tokens outstanding (opensearch-project#5147)

Signed-off-by: Derek Ho <dxho@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
Signed-off-by: Craig Perkins <cwperx@amazon.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Persistent review updated to latest commit 78049ef

Signed-off-by: Craig Perkins <cwperx@amazon.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit 8b619cc.

PathLineSeverityDescription
src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java147mediumToken revocation is not atomic across the cluster. revokeApiToken() updates the index and then calls notifyAboutChanges(), which triggers reloadApiTokensFromIndex() on each node via ApiTokenUpdateAction. Between the index write and the per-node cache flush there is a window where a revoked token remains valid in memory. A slow or partitioned node may continue accepting a revoked token indefinitely until its next successful reload.
src/main/java/org/opensearch/security/http/ApiTokenAuthenticator.java75lowAuthentication failures (invalid token, expired token, metadata missing) are logged at log.error() rather than log.debug() or log.warn(). This will flood error logs under any brute-force or token-scanning attempt, potentially masking real errors and causing log-based denial-of-service noise.
src/main/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandler.java105lowToken metadata is fetched with a hardcoded size of 10,000 results and no pagination. If the token index grows beyond this limit (possible given maxTokens is configurable up to 1,000 per config entry but the index itself has no enforced upper bound from the index handler), excess tokens are silently dropped from the in-memory cache, causing valid tokens to appear invalid.

The table above displays the top 10 most important findings.

Total: 3 | Critical: 0 | High: 0 | Medium: 1 | Low: 2


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Persistent review updated to latest commit 8b619cc

Signed-off-by: Craig Perkins <cwperx@amazon.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Persistent review updated to latest commit f0772a5

@cwperks cwperks added the skip-diff-analyzer Maintainer to skip code-diff-analyzer check, after reviewing issues in AI analysis. label May 5, 2026
Signed-off-by: Craig Perkins <cwperx@amazon.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Persistent review updated to latest commit 39b5609

Signed-off-by: Craig Perkins <cwperx@amazon.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Persistent review updated to latest commit d85f1eb

Signed-off-by: Craig Perkins <cwperx@amazon.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Persistent review updated to latest commit a85a39e

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Persistent review updated to latest commit 67105b5

Copy link
Copy Markdown
Collaborator

@nibix nibix left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked out the integration with the new privilege evaluator. There are a few things to be considered, see below. If you'd need more background on this, we can have a quick conversation.

Comment thread src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java Outdated
Comment thread src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Persistent review updated to latest commit 5ead3fc

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Failed to generate code suggestions for PR

Signed-off-by: Craig Perkins <cwperx@amazon.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Persistent review updated to latest commit 42ecbe0

Copy link
Copy Markdown
Member

@DarshitChanpura DarshitChanpura left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is some great work! @cwperks .

I left a few comments and clarification questions.

static {
PARSER.declareString(constructorArg(), new ParseField(NAME_FIELD));
PARSER.declareString(constructorArg(), new ParseField(TOKEN_HASH_FIELD));
PARSER.declareStringArray(optionalConstructorArg(), new ParseField(CLUSTER_PERMISSIONS_FIELD));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the purpose of making cluster permissions optional too?

Copy link
Copy Markdown
Member Author

@cwperks cwperks May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because then it allows having narrowly scoped tokens. i.e. Search on indexA only.

^ why have the caller pass cluster_permissions: [] unnecessarily?

apiTokenRepository.getTokenCount(ActionListener.wrap(tokenCount -> {
ConfigV7 config = configurationRepository.getConfiguration(CType.CONFIG).getCEntry(CType.CONFIG.name());
int maxTokens = config.dynamic.api_tokens.getMaxTokens();
if (tokenCount >= maxTokens) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at some point in future, we should add a schedule job to cleanup expired/revoked tokens.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? They are retained for auditability. Cluster admins wanted to delete metadata from revoked tokens can do so using admin cert auth.

Comment thread src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java Outdated
client.threadPool().getThreadContext().putHeader(ConfigConstants.OPENDISTRO_SECURITY_CONF_REQUEST_HEADER, "true");

SearchRequest searchRequest = new SearchRequest(ConfigConstants.OPENSEARCH_API_TOKENS_INDEX);
searchRequest.source(new SearchSourceBuilder().size(10_000));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add a filter to not get revoked/expired tokens?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@finnegancarroll had a similar comment above. I will address if the maintainers would like, but IMO its not necessary. This is done because of the UX in security-dashboards-plugin where it will show revoked and expired tokens..similar to Github with personal access tokens. It won't be supported with just this PR, but we should support renewing tokens with the same metadata in the future.

}
}

// SHA-256 is sufficient for hashing high-entropy random tokens. Consider making configurable if algorithm rotation is needed.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should consider this in next-iteration 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hence the comment :). If anyone in the community needs more flexibility then it should be obvious where to add it.

Comment thread src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java Outdated
Comment thread src/main/java/org/opensearch/security/user/User.java Outdated
apiTokenRepository.reloadApiTokensFromIndex(
ActionListener.wrap(
unused -> log.debug("API tokens loaded on node start"),
e -> log.warn("Failed to load API tokens on node start", e)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a mechanism where admin would know without looking at this warning?

maybe we show it in dashboard?

import static org.hamcrest.Matchers.equalTo;
import static org.junit.Assert.assertEquals;

public class ApiTokenTest {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add a test for testing all the optional arguments.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a mix of tests throughout that test different permissions payloads that may or may not include cluster_permissions or index_permissions. I will double check.

public AuditLogsRule auditLogsRule = new AuditLogsRule();

@Test
public void testApiTokenAuthenticationIsAudited() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also add a test about any changes to the token being audited.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test for revoke and corresponding entry

@github-actions
Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit aaf3fc4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-diff-analyzer Maintainer to skip code-diff-analyzer check, after reviewing issues in AI analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RFC] Support for API Keys in OpenSearch Security Plugin

6 participants